import { auth } from "@nextsparkjs/core/lib/auth"; import { toNextJsHandler } from "better-auth/next-js"; import { NextRequest, NextResponse } from "next/server"; import { TEAMS_CONFIG, AUTH_CONFIG } from "@nextsparkjs/core/lib/config"; import { isPublicSignupRestricted } from "@nextsparkjs/core/lib/teams/helpers"; // Registration helpers available if needed: shouldBlockSignup, isDomainAllowed // Currently domain validation happens in auth.ts databaseHooks import { TeamService } from "@nextsparkjs/core/lib/services"; import { wrapAuthHandlerWithCors, handleCorsPreflightRequest, addCorsHeaders } from "@nextsparkjs/core/lib/api/helpers"; import { checkDistributedRateLimit } from "@nextsparkjs/core/lib/api/rate-limit"; import { withSignupContext } from "@nextsparkjs/core/lib/auth-context"; const handlers = toNextJsHandler(auth); // Handle CORS preflight requests for cross-origin auth (mobile apps, etc.) export async function OPTIONS(req: NextRequest) { return handleCorsPreflightRequest(req); } // Intercept email verification requests to redirect to UI page // eslint-disable-next-line @typescript-eslint/no-unused-vars export async function GET(req: NextRequest, context: { params: Promise<{ all: string[] }> }) { const pathname = req.nextUrl.pathname; // Check if this is an email verification request from an email link // We check for a special header to determine if it's from our UI or from an email click const isFromUI = req.headers.get('x-verify-from-ui') === 'true'; if (pathname === '/api/auth/verify-email' && !isFromUI) { const token = req.nextUrl.searchParams.get('token'); const callbackURL = req.nextUrl.searchParams.get('callbackURL'); if (token) { // This is from an email link, redirect to the UI verification page const redirectUrl = new URL('/verify-email', req.url); redirectUrl.searchParams.set('token', token); if (callbackURL) { redirectUrl.searchParams.set('callbackURL', callbackURL); } return NextResponse.redirect(redirectUrl); } } // OAuth callbacks (e.g. Google) are browser GET redirects from the provider, // so they cannot carry the `x-signup-intent` header that header-based signup // uses. For first-time social signups, the client sets a short-lived // `signup-intent` cookie before initiating the OAuth flow; the cookie survives // the round-trip and is read here so the callback runs within signup context // and the user.create.after hook maps it to the initial team role // (AUTH_CONFIG.signupIntent), exactly as header-based signup does. Trust model // matches the header: the value only maps to an app-configured role via // roleMap (never an arbitrary role), so it cannot be used to escalate. const isOAuthCallback = pathname.includes('/api/auth/callback/'); const signupIntent = isOAuthCallback ? (req.cookies.get('signup-intent')?.value || undefined) : undefined; // Wrap with CORS headers for cross-origin requests (mobile apps, etc.) return wrapAuthHandlerWithCors( signupIntent ? () => withSignupContext({ signupIntent }, () => handlers.GET(req)) : () => handlers.GET(req), req ); } // Intercept signup requests to validate registration mode export async function POST(req: NextRequest) { // Rate limiting: 5 requests per 15 minutes per IP (tier: auth). // Protects login/signup against brute-force and credential stuffing attacks. // IP extraction strategy: // - Cloudflare: cf-connecting-ip (set by Cloudflare, not spoofable behind CF) // - Vercel/trusted proxies: rightmost non-private IP in x-forwarded-for // - Fallback: x-real-ip or 'unknown' const clientIp = (() => { // Cloudflare sets this header and it cannot be spoofed when behind CF const cfIp = req.headers.get('cf-connecting-ip') if (cfIp) return cfIp // x-forwarded-for: use rightmost entry (last proxy-appended value is most trustworthy) const forwardedFor = req.headers.get('x-forwarded-for') if (forwardedFor) { const ips = forwardedFor.split(',').map(ip => ip.trim()).filter(Boolean) if (ips.length > 0) return ips[ips.length - 1] } return req.headers.get('x-real-ip') || 'unknown' })() const rateLimitResult = await checkDistributedRateLimit(`auth:ip:${clientIp}`, 'auth') if (!rateLimitResult.allowed) { return new NextResponse(JSON.stringify({ error: 'Too many requests' }), { status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': '900', 'X-RateLimit-Limit': rateLimitResult.limit.toString(), 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': rateLimitResult.resetTime.toString(), }, }) } const pathname = req.nextUrl.pathname; // Determine request type // Comprehensive signup endpoint detection to prevent bypasses const signupEndpoints = [ '/sign-up/email', '/sign-up/credentials', '/signup', '/register', ]; const isSignupAttempt = signupEndpoints.some(endpoint => pathname.includes(endpoint)); const isOAuthCallback = pathname.includes('/api/auth/callback/'); const isSignupRequest = isSignupAttempt || isOAuthCallback; // A first-time OTP sign-in (emailOTP plugin with disableSignUp:false) auto-creates // the user, so it is an implicit signup that can also carry an intent. It is a // sign-in endpoint, so it is deliberately NOT part of `isSignupRequest` above // (no registration-mode gating) — it only participates in the intent wrapping // below, exactly like header/OAuth signup. const isOtpSignin = pathname.includes('/sign-in/email-otp'); if (isSignupRequest) { const registrationMode = AUTH_CONFIG?.registration?.mode ?? 'open'; const teamsMode = TEAMS_CONFIG.mode; // --- Registration mode enforcement --- // 1. Domain-restricted mode: block email signup, allow OAuth (validated in database hooks) if (registrationMode === 'domain-restricted' && isSignupAttempt && !isOAuthCallback) { // Block direct email/password signup in domain-restricted mode // Only Google OAuth is allowed (domain validation happens in database hooks) const errorResponse = NextResponse.json( { error: 'Email signup disabled', message: 'Please sign up with Google using an authorized email domain.', code: 'EMAIL_SIGNUP_DISABLED', }, { status: 403 } ); return await addCorsHeaders(errorResponse, req); } // Note: OAuth domain validation happens in auth.ts databaseHooks (user.create.before) // The hook throws an error if the email domain is not in allowedDomains // 2. Invitation-only mode OR single-tenant teams mode: existing behavior if (registrationMode === 'invitation-only' || isPublicSignupRestricted(teamsMode)) { const teamExists = await TeamService.hasGlobal(); if (teamExists) { const errorResponse = NextResponse.json( { error: 'Registration is closed', message: 'This application requires an invitation to register. Please contact an administrator.', code: 'SIGNUP_RESTRICTED', }, { status: 403 } ); return await addCorsHeaders(errorResponse, req); } } } // Read the optional signup intent and run the signup within request-scoped // context so the user.create.after hook can map it to an initial team role // (AUTH_CONFIG.signupIntent). Header-based signup carries it in the // `x-signup-intent` header; a first-time OTP sign-in carries it the same way // (header preferred, `signup-intent` cookie fallback); OAuth callbacks (incl. // form_post-mode providers that POST the callback) can't send a header, so // they fall back to the cookie set by the client before the OAuth flow (see // the GET handler). The value only ever maps to an app-configured role via // roleMap (never an arbitrary role) and the user.create.after hook runs only // when a user is actually created, so wrapping an existing-user sign-in is a // no-op — there is no escalation surface. const signupIntent = (isSignupAttempt || isOtpSignin) ? (req.headers.get('x-signup-intent') || req.cookies.get('signup-intent')?.value || undefined) : isOAuthCallback ? (req.cookies.get('signup-intent')?.value || undefined) : undefined; // Wrap with CORS headers for cross-origin requests (mobile apps, etc.) return wrapAuthHandlerWithCors( signupIntent ? () => withSignupContext({ signupIntent }, () => handlers.POST(req)) : () => handlers.POST(req), req ); }